简介
Spring Security是Spring家族中的一个安全管理框架。相比于另外一个安全框架Shiro,Shiro是一个轻量级的框架而Spring Security更重,但Spring Security的功能更丰富。一般来说中大型项目偏向于使用Spring Security来做安全框架,小型项目使用Shiro比较多。
一般Web应用都需要进行认证和授权,而这也是Spring Security的核心功能。
认证:验证当前访问系统的是不是本系统的用户,并且要确认具体是哪个用户。(通俗的将,认证其实就是用户登录。)
授权:经过认证后判断当前用户是否有权限执行某个操作。
helloworld
下面演示一个Spring Security最简单的示例。
新建一个模块,pom.xml文件中只引入如下两个依赖:1
2
3
4
5
6
7
8
9<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
注意,此处我是用的spring-boot-starter-security
版本是 2.1.5.RELEASE,对应的 spring security 的版本是 5.1.5.RELEASE,spring security 在5.7版本及以后相比于早期版本有比较大的变化,这里需要特别注意一下。
新建一个controller类。1
2
3
4
5
6
7
8
9
10
public class HelloController {
"/hello") (
public String hello() {
return "hello";
}
}
启动后,在浏览器中访问地址:http://localhost:8080/springsecurity/hello (因为我配置了server.servlet.context-path为/springsecurity
),会跳转到Spring Security自带的一个登录页面。
这个页面登录默认的用户名是user,密码则是在项目启动时日志中输出的一个密码。
登录之后,就可以访问 http://localhost:8080/springsecurity/hello 这个接口了。
从这个示例中,我们看到了Spring Security做的一个最简单的认证功能。但是实际项目中,肯定不能这么使用。首先,用户肯定不止一个,而且用户和密码也都是保存在数据库中,是动态的数据。其次,上面并没有授权管理相关的功能,这一部分也需要做进一步扩展。另外,Spring Security 默认的这种认证方式是基于session的,但对于现在前后端分离的项目,前端除了web浏览器,也可能是app或者小程序,所以默认的认证方式是有局限性的。事实上,在移动平台上,一般在登录后接口返回一个token,前端将token保存在本地,然后每次请求时都带上这个token,后端会通过这个token来识别用户并判断访问权限。
登出(logout)
Spring Security也自带了登出的接口(logout),比如上面的示例项目的登出接口就是http://localhost:8080/springsecurity/logout,访问了该接口之后会跳转回登录页面,需要重新登录后才能再次访问http://localhost:8080/springsecurity/hello这个地址。
改进第一步:数据库校验用户
上面我们提到,SpringS ecurity默认就一个固定的用户名为user的用户,但实际项目中,用户数量有很多,并且用户数据是保存在数据库的,并且像用户密码这种敏感信息在数据库中也是加密保存的,那在SpringSecurity中怎么来实现这个功能呢?
首先介绍SpringSecurity中一个重要的接口:UserDetailsService
,这个接口中只有一个方法。1
UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
作用是根据用户名,从数据库中查询出该用户名对应的相关数据,封装称为一个UserDetails
的对象,如果没有找到对应的用户,则抛出UsernameNotFoundException
异常。
UserDetails
也是一个接口,它并不是Spring Security用来做安全控制的,只是用来保存一些用户信息并封装到Authentication
对象中去。Spring Security中已经有一个UserDetails
的实现类User
,我们可以直接使用User
或者继承User
,也可以自己实现UserDetails
接口。
在本节的改造中,我们定义一个SysUser
类实现UserDetails
类。
1 | package com.lzumetal.springsecurity.entity; |
然后再写一个UserDetailsService
的实现类。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31package com.lzumetal.springsecurity.service;
import com.lzumetal.springsecurity.entity.SysUser;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
public class UserDetailsServiceImpl implements UserDetailsService {
private SysUserService sysUserService;
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
SysUser userFromDb = sysUserService.findUser(username);
if (userFromDb == null) {
throw new UsernameNotFoundException(username);
}
return userFromDb;
}
}
再写一个 SysUserService
service类,用来模拟从数据库查询的功能。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48package com.lzumetal.springsecurity.service;
import com.google.common.collect.Lists;
import com.google.common.collect.Sets;
import com.lzumetal.springsecurity.entity.SysUser;
import org.springframework.stereotype.Service;
import java.util.*;
/**
* @author liaosi
* @date 2022-08-09
*/
public class SysUserService {
private static List<SysUser> usersFromDb = new ArrayList<>();
static {
usersFromDb.add(new SysUser(1,
"zhangsan",
"$2a$10$2A9boPmN4EQiah93ypuNNuJV1lSb9bMVigsOhAjI8s2yOP0u5r2W2", //密码是123,BCryptPasswordEncoder加密
"张三"));
usersFromDb.add(new SysUser(2,
"lisi",
"$2a$10$ddbqmCp1WZJlDXVxBdlFH.l2keTK1fGfG5yt4OX8YUDgTF4fs2iGO", //密码是456,BCryptPasswordEncoder加密
"李四"));
}
/**
* 模拟根据用户名从数据库中查询数据
*
* @param username
* @return
*/
public SysUser findUser(String username) {
for (SysUser sysUser : usersFromDb) {
if (sysUser.getUsername().equals(username)) {
return sysUser;
}
}
return null;
}
}
因为在这个例子中,密码是加密过的,所以当前端页面用户提交了用户名和密码之后,Spring Seurity也需要对密码加密后再和保存的密码去比对,因此我们就必须告诉Spring Security应该要怎么去加密密码,做法是向Spring容器中注入一个PasswordEncoder
类型的实例,我们可以自己去实现PasswordEncoder
接口,也可以使用pring Security自带的一些实现类,在这个例子中我们使用自带的实现类BCryptPasswordEncoder
,这也是官方推荐的一个实现类。
1 | package com.lzumetal.springsecurity.config; |
再次运行项目,在浏览器中访问地址:http://localhost:8080/springsecurity/hello,跳到登录页面后,我们就可以使用 zhangsan 和 lisi 这两个账号登录了,并且密码错误的话,也会有相应的提示。至此,我们完成了改进的第一步。
改进第二步:使用JWT
在上面的例子中,我们在登录之后就可以访问一直访问 http://localhost:8080/springsecurity/hello 这个url了,实际上是依靠 session 来判断用户是否登录并且区分具体是哪个用户,在登录请求中我们也看到服务器设置了name=JSESSIONID的token。
session认证的问题
传统的session认证有如下的问题:
- 每个用户的登录信息都会保存到服务器的session中,随着用户的增多,服务器开销会明显增大。
- 由于session是存在与服务器的物理内存中,所以在分布式系统中,这种方式将会失效。虽然可以将session统一保存到Redis中,但是这样做无疑增加了系统的复杂性,对于不需要redis的应用也会白白多引入一个缓存中间件。
- 对于非浏览器的客户端、手机移动端等不适用,因为session依赖于cookie,而移动端经常没有cookie。
- 因为session认证本质基于cookie,所以如果cookie被截获,用户很容易收到跨站请求伪造攻击。并且如果浏览器禁用了cookie,这种方式也会失效。
- 前后端分离系统中更加不适用,后端部署复杂,前端发送的请求往往经过多个中间件到达后端,cookie中关于session的信息会转发多次。
- 由于基于Cookie,而cookie无法跨域,所以session的认证也无法跨域,对单点登录不适用。
正因为session认证存在这些问题,所以后来我们引入了一种新的方式:token认证方式,这时就轮到JWT登场了。
JWT是什么
JWT是 JSON WEB TOKEN 的简写,它其实就是 token 实现的一种具体方案。
通俗地说,JWT的本质就是一个加密后的字符串,它是将用户信息保存到一个Json字符串中,然后进行编码后得到一个JWT token,并且这个JWT token带有签名信息,接收后可以校验是否被篡改,所以可以用于在客户端和服务端之间安全地传输数据。
JWT的认证流程
JWT的认证流程如下:
- 首先,前端通过Web表单将自己的用户名和密码发送到后端的接口,这个过程一般是一个POST请求。建议的方式是通过SSL加密的传输(HTTPS),从而避免敏感信息被嗅探
- 后端核对用户名和密码成功后,将包含用户信息的数据作为JWT的Payload,将其与JWT Header分别进行Base64编码拼接后签名,形成一个JWT Token,形成的JWT Token就是一个如同lll.zzz.xxx的字符串
- 后端将JWT Token字符串作为登录成功的结果返回给前端。前端可以将返回的结果保存在浏览器中,退出登录时删除保存的JWT Token即可。
- 前端在每次请求时将JWT Token放入HTTP请求头中的Authorization属性中(解决XSS和XSRF问题)
- 后端检查前端传过来的JWT Token,验证其有效性,比如检查签名是否正确、是否过期、token的接收方是否是自己等等
- 验证通过后,后端解析出JWT Token中包含的用户信息,进行其他逻辑操作(一般是根据用户信息得到权限等),返回结果。
JWT认证的优点
- 简洁:JWT Token数据量小,传输速度也很快。
- 因为JWT Token是以JSON加密形式保存在客户端的,所以JWT是跨语言的,原则上任何web形式都支持。
- 不需要在服务端保存会话信息,也就是说不依赖于cookie和session,所以没有了传统session认证的弊端,特别适用于分布式微服务。
- 单点登录友好:使用Session进行身份认证的话,由于cookie无法跨域,难以实现单点登录。但是,使用token进行认证的话, token可以被保存在客户端的任意位置的内存中,不一定是cookie,所以不依赖cookie,不会存在这些问题。
- 适合移动端应用:使用Session进行身份认证的话,需要保存一份信息在服务器端,而且这种方式会依赖到Cookie(需要 Cookie 保存 SessionId),所以不适合移动端。
JWT结构
JWT由3部分组成:标头(Header)、有效载荷(Payload)和签名(Signature)。这三个部分用‘.’号分隔,比如下面的这个:1
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjMfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
具体的生成方式类似如下:1
JWT String=Base64(Header).Base64(Payload).HMACSHA256(base64UrlEncode(header)+"."+base64UrlEncode(payload),secret)
Header
JWT头是一个描述JWT元数据的JSON对象,alg属性表示签名使用的算法,默认为HMAC SHA256(写为HS256);typ属性表示令牌的类型,JWT令牌统一写为JWT。最后,将上述JSON对象使用Base64编码。1
2
3
4{
"alg": "HS256", // 加密的算法
"typ": "JWT" // 加密的方式,填写JWT
}
Payload
有效载荷部分,是JWT的主体内容部分,也是一个JSON对象,包含需要传递的数据。 JWT指定七个默认字段供选择。1
2
3
4
5
6
7
8
9{
"iss": "xxx", //发行人
"exp": "xxx", //到期时间
"sub": "xxx", //主题
"aud": "xxx", //用户
"nbf": "xxx", //在此之前不可用
"iat": "xxx", //发布时间
"jti": "xxx" //JWT ID用于标识该JWT
}
这些预定义的字段并不要求强制使用。除以上默认字段外,我们还可以自定义私有字段,一般会把包含用户信息的数据放到payload中,如下例:1
2
3
4
5{
"sub": "1234567890",
"name": "Helen",
"admin": true
}
注意,默认情况下JWT的Header和Payload是未加密的,因为只是采用base64算法,拿到JWT字符串后可以转换回原本的JSON数据,任何人都可以解读其内容,因此不要构建隐私信息字段,比如用户的密码一定不能保存到JWT中,以防止信息泄露。JWT只是适合在网络中传输一些非敏感的信息。
Signature
签名哈希部分是对上面两部分数据签名,需要使用base64编码后的header和payload数据,通过指定的算法生成哈希,以确保数据不会被篡改。首先,需要指定一个密钥(secret)。该密码仅仅为保存在服务器中,并且不能向用户公开。然后,使用header中指定的签名算法(默认情况下为HMAC SHA256)根据以下公式生成签名。1
HMACSHA256(base64UrlEncode(header)+"."+base64UrlEncode(payload),secret)
小总结
在服务端接收到客户端发送过来的JWT token之后:
- header和payload可以直接利用base64解码出原文,从header中获取哈希签名的算法,从payload中获取有效数据。
- signature由于使用了不可逆的加密算法,无法解码出原文,它的作用是校验token有没有被篡改。服务端获取header中的加密算法之后,利用该算法加上secretKey对header、payload进行加密,比对加密后的数据和客户端发送过来的是否一致。注意secretKey只能保存在服务端,而且对于不同的加密算法其含义有所不同,一般对于MD5类型的摘要加密算法,secretKey实际上代表的是盐值。
JWT的使用
接下来我们介绍一下怎么使用JWT,在JWT的官网上https://jwt.io/我们可以看到有多个开源库可以选择,Java语言比较推荐使用的是java-jwt和jjwt,本文中使用jjwt这个库。
首先在pom.xml文件中引入依赖。1
2
3
4
5<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
我们可以写一个类似如下面的JWT工具类,当用户登录成功后,生成token返回给前端,此后前端在请求接口时,在header中带上该token,后端拿到token后解析出用户的信息。
1 | package com.lzumetal.springsecurity.util; |
因为Spring Security它自带了默认的登录接口,我们要使用JWT的话,就得把登录接口改成如下这个我们自己定义的接口,并且Spring Security必须对这个接口放行,不需要登录也可以访问该接口。
首先写一个登录接口。
1 | package com.lzumetal.springsecurity.controller; |
下一步,我们应该考虑LoginService
的userLogin
方法应该怎么实现。首先肯定是需要对用户名和密码进行验证,验证成功之后再生成一个token返回给前端,代码如下。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47package com.lzumetal.springsecurity.service;
import com.lzumetal.springsecurity.common.ServiceException;
import com.lzumetal.springsecurity.entity.SysUser;
import com.lzumetal.springsecurity.util.JWTUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
public class LoginService {
"userDetailsServiceImpl") (
private UserDetailsService userDetailsService;
private PasswordEncoder passwordEncoder;
public String userLogin(String username, String password) {
// 登陆检测
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if(null == userDetails || !passwordEncoder.matches(password, userDetails.getPassword())) {
throw new ServiceException(405, "用户名或密码不正确!");
}
//使用userid生成token
SysUser sysUser = (SysUser) userDetails;
Integer userId = sysUser.getId();
if (userId == null) {
throw new RuntimeException("服务器错误");
}
return JWTUtils.generateJwtToken(String.valueOf(userId));
}
}
然后我们配置对user/login
接口放行,具体做法是继承WebSecurityConfigurerAdapter
这个适配器类,重写它的config方法,用来配置对我们自定的登录接口放行。在本例中我们将前面已写的SpringSecurityConfig
类来继承WebSecurityConfigurerAdapter
。
注意,在Spring Security 5.7版本之后,WebSecurityConfigurerAdapter
类已经标记为过时,改成了配置一个SecurityFilterChain
实例来实现同样的功能。
1 | package com.lzumetal.springsecurity.config; |
OK,启动项目后在postman中调用/user/login
接口,成功返回token,并能使用前面写的工具类可以根据token解析出userId。并且如果密码输入错误,也能返回异常提示。
认证过滤器
登录逻辑完成后,接下来就需要通过一个过滤器或者拦截器对需要认证才能访问的资源在每次请求时先验证token,通过token解析出用户id后,再查出对应的权限,将权限保存在Spring Security的上下文中,随后将在Spring Security将会和配置好的每个资源的权限去比较,决定是否放行该求。
也可以将权限直接保存的token里,因为认证服务一般和业务服务是分开的,业务中不会在去查用户的权限。
或者如果有一个网关(geteway)服务的话,在网关中再去查询权限可能是一种更好的方案。
这里,我们写一个过滤器并继承OncePerRequestFilter
,OncePerRequestFilter
是Spring Boot里面的一个过滤器抽象类,其同样在Spring Security里面被广泛用到,继承了该类的过滤器在每次请求时只执行一次过滤,对于内部的转发(forward)不会进行过滤。
1 | package com.lzumetal.springsecurity.fillter; |
我们再模拟一下前面两个用户的权限,修改SysUserService
类。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65package com.lzumetal.springsecurity.service;
import com.google.common.collect.Lists;
import com.google.common.collect.Sets;
import com.lzumetal.springsecurity.entity.SysUser;
import org.springframework.stereotype.Service;
import java.util.*;
public class SysUserService {
private static List<SysUser> usersFromDb = new ArrayList<>();
/**
* 模拟用户权限,key为用户id,value为权限集合
*/
private static Map<Integer, Set<String>> permissions = new HashMap<>();
static {
usersFromDb.add(new SysUser(1,
"zhangsan",
"$2a$10$2A9boPmN4EQiah93ypuNNuJV1lSb9bMVigsOhAjI8s2yOP0u5r2W2", //密码是123,BCryptPasswordEncoder加密
"张三"));
usersFromDb.add(new SysUser(2,
"lisi",
"$2a$10$ddbqmCp1WZJlDXVxBdlFH.l2keTK1fGfG5yt4OX8YUDgTF4fs2iGO", //密码是456,BCryptPasswordEncoder加密
"李四"));
permissions.put(1, Sets.newHashSet("ROLE_ADMIN", "test", "write"));
permissions.put(2, Sets.newHashSet("ROLE_USER", "test", "read"));
}
/**
* 模拟根据用户名从数据库中查询数据
*
* @param username
* @return
*/
public SysUser findUser(String username) {
for (SysUser sysUser : usersFromDb) {
if (sysUser.getUsername().equals(username)) {
return sysUser;
}
}
return null;
}
public SysUser findById(Integer userId) {
for (SysUser sysUser : usersFromDb) {
if (Objects.equals(sysUser.getId(), userId)) {
return sysUser;
}
}
return null;
}
public Set<String> getUserAuthority(Integer userId) {
return permissions.get(userId);
}
}
然后再controller中增加一个/admin
的接口。1
2
3
4"/admin") (
public String admin() {
return "hello admin";
}
并在Spring Security的配置类中配置只有admin角色才能访问,并且将JwtAuthenticationFilter
这个过滤器加入到Spring Security的过滤器链中。(Spring Security的认证授权功能就是通过过滤器链来实现的。)
1 | package com.lzumetal.springsecurity.config; |
然后就是测试了,先用 zhangsan 这个账号登录,拿到token,访问/hello
和/admin
接口都是正常的。使用 lisi 这个账号登录的token,访问/hello
接口正常,访问/admin
接口会返回403(Forbbiden)。1
2
3
4
5
6
7{
"timestamp": "2022-08-10T14:10:20.381+0000",
"status": 403,
"error": "Forbidden",
"message": "Forbidden",
"path": "/springsecurity/admin"
}
这就验证了我们的配置是OK的,达到了想要的认证和授权的目的。
基于注解的访问控制
Spring Security也提供了几个注解,用于替代前面用代码的配置方式,以.antMatchers("/**/admin").hasRole("ADMIN")
为例,首先我们在配置类上加一个@EnableGlobalMethodSecurity
注解。
@Secured
第一种方式是使用@Secured
注解,方法如下。
首先将@EnableGlobalMethodSecurity
注解的securedEnabled设置为true,然后对应的controller或者方法上加上@Secured
注解,通过注解参数可以设置该类或者该方法的权限。
配置类1
2
3
4
5
6
true) (securedEnabled =
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
//省略......
}
接口方法1
2
3
4
5"ROLE_ADMIN") (
"/admin") (
public String admin() {
return "hello admin";
}
@PreAuthorize
和@PostAuthorize
第二种方式是使用@PreAuthorize
注解。@PreAuthorize
和@PostAuthorize
也是方法或类级别的注解。@PreAuthorize
:表示在访问方法或类之前判断权限,大多数情况下都是使用这个注解,注解的参数和access()方法参数取值相同,都是权限表达式。@PostAuthorize
:表示方法或类执行结束后判断权限,此注解很少被使用到。
如下是代码示例。
配置类1
2
3
4
5
6
true) (prePostEnabled =
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
//省略......
}
接口方法1
2
3
4
5"hasAnyRole('ADMIN')") (
"/admin") (
public String admin() {
return "hello admin";
}
本文示例代码已上传github:https://github.com/liaosilzu2007/springsecurity-auth2-jwt。